獨孤九劍是獨孤求敗所創,在武林內是至高無上的劍法,總共有九式。以無用之用為大用為原則,根據觀察對方招式,迅速找到破綻,而攻擊方法沒有固定,完全視對手招式而定,遇強則強。在撰寫自動化測試,也有所謂的九式,在這之前,先介紹測試模組的概念,當作自動化測試的設計模式的基礎。
在撰寫自動化測試時,隨著案例數量與業務複雜度上升,測試程式碼也容易變得凌亂。多數專案的基本操作其實能被重複使用;只要產品架構沒有劇烈變動,把這些操作抽成「測試模組」能大幅提升可讀性、可維護性與重複使用性。實務上,最重視的一點是:測試案例的「測試意圖」要被清楚表達。如果閱讀測試時,常常需要追到產品原始碼才能知道在測什麼,通常代表測試寫得太抽象或細節分散。
在自動化測試會需要的開發模組,通常可以包含:
先從一個最小可行的登入測試開始(未重構):
// tests/login-basic.spec.ts
import { test, expect } from '@playwright/test';
test('正常登入', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('input[name="username"]', 'testuser');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page.getByText('Welcome')).toBeVisible();
});
接著我們往往會加入更多登入相關案例:
這個範例可以執行並且得到正確的結果,這時會遇到三個典型痛點::
"input[name='username']"
,無法直接理解測試案例的意圖。因此,我們將一步步重構,將登入流程、帳號密碼並且隱藏細節,接著讓所有測試案例都可以使用到這個測試模組,而不用重複撰寫非測試案例無關的細節。
在開始重構之前,我們可以先看使用測試模組的測試案例會長得像怎麼樣子,裡面總共會有上述的五個測試案例:
// tests/userlogin.spec.ts
import { test, expect } from '@playwright/test';
test('正常登入', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('input[name="username"]', 'testuser');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page.getByText('Welcome')).toBeVisible();
});
test('錯誤密碼', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('input[name="username"]', 'testuser');
await page.fill('input[name="password"]', 'wrongpass');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toHaveText(/帳號或密碼錯誤/);
});
test('錯誤帳號', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('input[name="username"]', 'nouser');
await page.fill('input[name="password"]', 'any-pass');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toHaveText(/帳號或密碼錯誤/);
});
test('空白輸入', async ({ page }) => {
await page.goto('https://example.com/login');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toHaveText(/請輸入帳號與密碼/);
});
test('帳號被鎖定', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('input[name="username"]', 'locked_user');
await page.fill('input[name="password"]', 'any-pass');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toHaveText(/此帳號已被停用/);
});
最終版本,重構流程與步驟在後面。
// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../modules/login.page';
const CREDENTIALS = {
ok: { user: 'testuser', pass: 'password123' },
wrongPass: { user: 'testuser', pass: 'wrongpass' },
wrongUser: { user: 'nouser', pass: 'any-pass' },
locked: { user: 'locked_user', pass: 'any-pass' },
};
test.describe('登入測試(使用模組化)', () => {
test('正常登入', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login(CREDENTIALS.ok.user, CREDENTIALS.ok.pass);
await login.expectWelcome();
});
test('錯誤密碼', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login(CREDENTIALS.wrongPass.user, CREDENTIALS.wrongPass.pass);
await login.expectErrorContains(/帳號或密碼錯誤/);
});
test('錯誤帳號', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login(CREDENTIALS.wrongUser.user, CREDENTIALS.wrongUser.pass);
await login.expectErrorContains(/帳號或密碼錯誤/);
});
test('空白輸入', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
// 不輸入任何東西,直接送出
await page.click('button[type="submit"]');
await login.expectErrorContains(/請輸入帳號與密碼/);
});
test('帳號被鎖定', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login(CREDENTIALS.locked.user, CREDENTIALS.locked.pass);
await login.expectErrorContains(/此帳號已被停用/);
});
});
當我開始重構的時候,我會先專注於某個測試案例,接著應用在下一個測試案例,如果遇到重複的程式碼,則會抽取函式,如果含是有特殊目的,則會在獨立一個模組出來。
簡單來說,步驟能夠分成下列幾個步驟:
"input[name='username']"
,無法直接理解測試案例的意圖。// modules/login.actions.ts
import { Page } from '@playwright/test';
export async function gotoLogin(page: Page) {
await page.goto('https://example.com/login');
}
export async function doLogin(page: Page, username: string, password: string) {
await page.fill('input[name="username"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]');
}
第一步我們只是抽出 login() function,第二步可以把「開啟登入頁」與「登入」兩個步驟,集中到一個 class 裡,讓測試更乾淨。
// modules/login.module.ts
import { Page } from '@playwright/test';
export class LoginModule {
constructor(private page: Page) {}
async goto() {
await this.page.goto('https://example.com/login');
}
async login(username: string, password: string) {
await this.page.fill('input[name="username"]', username);
await this.page.fill('input[name="password"]', password);
await this.page.click('button[type="submit"]');
}
}
// tests/login-basic.spec.ts
import { test, expect } from '@playwright/test';
import { LoginModule } from '../modules/login.module';
test('正常登入', async ({ page }) => {
const login = new LoginModule(page);
await login.goto();
await login.login('testuser', 'password123');
await expect(page.getByText('Welcome')).toBeVisible();
});
為了讓測試更有語意,我們可以在模組裡增加「驗證」方法,讓測試案例只寫「意圖」。
// modules/login.module.ts
import { Page, expect } from '@playwright/test';
export class LoginModule {
constructor(private page: Page) {}
async goto() {
await this.page.goto('https://example.com/login');
}
async login(username: string, password: string) {
await this.page.fill('input[name="username"]', username);
await this.page.fill('input[name="password"]', password);
await this.page.click('button[type="submit"]');
}
async expectWelcome() {
await expect(this.page.getByText('Welcome')).toBeVisible();
}
async expectErrorContains(text: RegExp | string) {
await expect(this.page.locator('.error-message')).toHaveText(text);
}
}
// tests/login-assertion.spec.ts
import { test } from '@playwright/test';
import { LoginModule } from '../modules/login.module';
test('正常登入', async ({ page }) => {
const login = new LoginModule(page);
await login.goto();
await login.login('testuser', 'password123');
await login.expectWelcome();
});
test('錯誤密碼', async ({ page }) => {
const login = new LoginModule(page);
await login.goto();
await login.login('testuser', 'wrongpass');
await login.expectErrorContains(/帳號或密碼錯誤/);
});
經過第三步,我們就可以把 LoginModule 放在 modules/login.module.ts,測試案例只要 import 使用。這樣一來會發現我們將所有的 selector 和操作有關的細節,都放在模組裡面維護。測試案例只表達「測試意圖」,例如:使用者正常登入、使用錯誤密碼、帳號不存在,並且也將驗證的函式獨立出來,閱讀程式碼的人可以很清楚明白測試案例要驗證的目標是什麼。
經過這樣重構後的版本,如果將來需要新增多個驗證方法,例如:loginWithGoogle()、loginWithSSO(),我們也不需要改動舊有的測試案例。這裡我們暫時稱為測試模組。後面,我們會正式介紹這種寫法就是 Page Object Model (POM)。
.
├─ modules/
│ └─ login.page.ts
├─ tests/
│ ├─ fixtures.ts
│ ├─ login-basic.spec.ts
│ ├─ login.spec.ts
│ └─ login-negative-ddt.spec.ts
├─ playwright.config.ts
└─ package.json
今天我們介紹什麼是測試模組,基本上就是將共同會使用的部分獨立抽出來變成一個模組,常用的模組有介面的操作、驗證函式或是基本的操作,通過這樣的操作可以讓多個測試案例共用不同的程式碼。這個測試模組是 Page Object Model (POM) 的前身。